feat(commands): add shelltime update self-update command#278
Conversation
Adds a top-level `shelltime update` command that downloads the latest release archive from GitHub, verifies its SHA256 against checksums.txt, extracts the bundled shelltime / shelltime-daemon binaries, and replaces the running install in place. After the swap it runs `shelltime daemon reinstall` so systemd/launchd picks up the new binary (opt-out via --skip-daemon-reinstall). Homebrew installs are detected (via the resolved executable path containing /Cellar/, /opt/homebrew/, or /home/linuxbrew/.linuxbrew/) and redirected to `brew upgrade shelltime/tap/shelltime` instead of being overwritten. Binaries at unrecognized locations are left untouched with a warning. Flags: --check (dry run), --force (override same-version / dev-build guards), --skip-daemon-reinstall. Pure logic (URL/archive-name builder, checksum parser, archive extractor with zip-slip guard, .bak-rename replace) lives in model/updater.go with table-driven tests in model/updater_test.go.
Codecov Report❌ Patch coverage is
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 4 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 865dca96e2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if shouldReinstallDaemon(ctx, skipDaemonReinstall) { | ||
| color.Yellow.Println("🔁 Refreshing daemon service...") | ||
| if err := commandDaemonReinstall(c); err != nil { | ||
| color.Yellow.Printf("⚠️ Daemon reinstall reported an error: %v\n", err) |
There was a problem hiding this comment.
Skip daemon reinstall after creating
.bak rollback state
Calling commandDaemonReinstall immediately after ReplaceBinary can undo the daemon update for curl installs. ReplaceBinary writes the new daemon to ~/.shelltime/bin/shelltime-daemon and leaves the old one at .bak, then commandDaemonReinstall invokes commandDaemonInstall, which restores .bak over the just-updated daemon. In the common case where the daemon service is running, shelltime update reports success but the daemon binary is reverted to the previous version.
Useful? React with 👍 / 👎.
| expectedSum, ok, err := model.FetchChecksum(ctx, latest, archiveName) | ||
| if err != nil { | ||
| color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err) | ||
| } else if !ok { |
There was a problem hiding this comment.
Fail closed when checksum retrieval errors occur
If FetchChecksum returns an error (network failure, 5xx, throttling), the update continues with expectedSum empty, so DownloadAndVerify performs no integrity check. This creates a fail-open path where checksum verification is silently bypassed for transient or induced errors, which weakens the security guarantees of self-update.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Code Review
This pull request implements an in-place update mechanism for the ShellTime CLI, including a new update command and supporting logic in the model package to fetch, verify, and install releases from GitHub. Feedback from the review focuses on enhancing security and robustness: it is recommended to fail the update process on checksum retrieval errors rather than failing open, to use io.LimitReader when decoding API responses to mitigate potential denial-of-service risks, and to explicitly handle errors when managing backup files during binary replacement.
| if err != nil { | ||
| color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err) | ||
| } else if !ok { | ||
| color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.") | ||
| } |
There was a problem hiding this comment.
The update process currently proceeds even if an error occurs while fetching or parsing the checksum file. While skipping verification for missing checksums (older releases) is intended, failing open on network errors or malformed checksum data is a security risk. It is safer to return an error in these cases to prevent potentially installing a corrupted or tampered binary.
| if err != nil { | |
| color.Yellow.Printf("⚠️ Could not fetch checksums.txt: %v (proceeding without verification)\n", err) | |
| } else if !ok { | |
| color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.") | |
| } | |
| if err != nil { | |
| return fmt.Errorf("fetch checksum: %w", err) | |
| } | |
| if !ok { | |
| color.Yellow.Println("⚠️ No checksum entry for this archive — proceeding without verification.") | |
| } |
| } | ||
|
|
||
| var rel LatestRelease | ||
| if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { |
There was a problem hiding this comment.
When decoding responses from external APIs, it is a best practice to use an io.LimitReader to prevent potential denial-of-service attacks from excessively large response bodies.
| if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { | |
| if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&rel); err != nil { |
| // old inode alive for the current process. | ||
| func ReplaceBinary(srcPath, destPath string) error { | ||
| bak := destPath + ".bak" | ||
| _ = os.Remove(bak) |
There was a problem hiding this comment.
Ignoring the error from os.Remove(bak) can lead to a failure in the subsequent os.Rename(destPath, bak) if the backup file exists but cannot be removed (e.g., due to permission issues or the file being in use). It is better to explicitly handle errors that are not 'file does not exist'.
if err := os.Remove(bak); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old backup: %w", err)
}
Summary
Adds a top-level
shelltime updatecommand that downloads the latest release archive from GitHub, verifies its SHA256, extracts the bundled binaries, and replaces the running install in place — mirroring whatinstall.bashdoes, but without leaving the shell.commands/update.go; pure logic (URL builder, archive name builder, checksum parser, archive extractor, replace-in-place) lives atmodel/updater.gowith table-driven tests atmodel/updater_test.go.https://github.com/shelltime/cli/releases/download/<tag>/cli_<OS>_<ARCH>.<ext>.checksums.txtfrom the same release. Warns and proceeds ifchecksums.txtis missing (older releases).shelltime,shelltime-daemon, and their.exevariants); rejects anything else as defense-in-depth against zip-slip / tar path traversal. Caps extracted-entry size at 200 MB to defeat zip-bombs..bakrename dance — same schemecommands/daemon.install.goalready uses to recover on upgrades./Cellar/,/opt/homebrew/, or/home/linuxbrew/.linuxbrew/— those users get abrew upgrade shelltime/tap/shelltimehint and the command exits without touching the binary. Binaries at unrecognized locations (e.g. someone manually placed shelltime in/usr/bin/) are left alone with a warning.commandDaemonReinstallso systemd/launchd reloads the new binary. Skipped on Windows, when the daemon isn't installed, or when--skip-daemon-reinstallis passed.Flags
--check/-c— print current vs latest version, do not install.--force/-f— proceed even on a dev build or when already on the latest version.--skip-daemon-reinstall— don't rundaemon reinstallafter the binary swap.Why
Today users can only upgrade by re-running the curl installer or
brew upgrade.shelltime updatemakes upgrades a single in-shell command and keeps the daemon service in sync with the new binary automatically.Test plan
go build ./cmd/cli/main.gobuilds cleanly.go test ./model/...passes (all newTestUpdater*cases — URL builder matrix, checksum parser happy/missing/malformed, zip-slip guard, normalize-version, install-kind classifier, zip extraction, tar.gz extraction, replace-binary with and without existing target).gofmt -lclean on all new / modified files.shelltime update --helplists the three flags.~/.shelltime/bin/shelltimeproceeds past install-kind detection and attempts the GitHub API call.shelltime update, confirm the binary is replaced (shelltime --versionbefore/after), confirm~/.shelltime/bin/shelltime.bakexists, confirm the daemon service restarts (systemctl --user status shelltime-daemonon Linux,launchctl list | grep shelltimeon macOS).Generated by Claude Code